package suncertify.db;

import suncertify.util.Messages;

import java.io.*;
import java.util.*;
import java.nio.channels.FileChannel;

/**
 * Implementation of DB interface. Non relational database with specific file format
 */
public class Data implements DB {
    private static final int MAGIC = 0x0203;
    private static final String ENC = "US-ASCII";
    private static final Messages msg = Messages.getInstance();
    private int fieldsCount;
    private long nextLockCookie = 1;
    private FieldDescription[] headers;
    private List<Row> rows = new ArrayList<Row>();
    private Queue<Integer> deleted = new LinkedList<Integer>();
    private String db;

    /**
     * returns the column name
     * @param col column index
     * @return the column name
     */
    public String getColumnName(int col) {
        String name;
        if (col >= 0 && col < headers.length) {
            name = headers[col].getName();
        } else {
            name = "unknown";
        }
        return name;
    }

    /**
     * returns columns count
     * @return columns count
     */
    public int getColumnCount() {
        return headers.length;
    }

    private class Row {
        private static final short VALID_FLAG = 0x00;
        private static final short INVALID_FLAG = 0xff;
        private boolean valid;
        private String[] data;
        private long lockCookie = -1;
        private long position = -1;

        public Row(String[] data) {
            this.valid = true;
            this.data = data;
        }

        public Row(int flag, String[] data, long position) throws IOException {
            if (flag == VALID_FLAG) {
                this.valid = true;
            } else if (flag == INVALID_FLAG) {
                this.valid = false;
            } else {
                throw new IOException(msg.get("db.error.flag", flag));
            }
            this.data = data;
            this.position = position;
        }

        public byte getFlag() {
            return (byte) (valid ? VALID_FLAG : INVALID_FLAG);
        }

        public boolean isValid() {
            return valid;
        }

        public String[] getData() {
            return data;
        }

        public void delete(long cookie) {
            checkAccess(cookie);
            valid = false;
        }

        public void setData(String[] data, long cookie) throws SecurityException {
            checkAccess(cookie);
            this.data = data;
        }

        public long getLockCookie() {
            return lockCookie;
        }

        public boolean isLocked() {
            return lockCookie > 0;
        }

        public synchronized void lock(long lockCookie) throws LockCancelledException {
            if (isLocked()) {
                try {
                    wait();
                } catch (InterruptedException e) {
                    throw new LockCancelledException();
                }
            }
            this.lockCookie = lockCookie;
        }


        public void unlock(long cookie) {
            checkAccess(cookie);
            synchronized (this) {
                lockCookie = -1;
                notify();
            }
        }

        public long getPosition() {
            return position;
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) {
                return true;
            }
            if (o == null || getClass() != o.getClass()) {
                return false;
            }
            Row row = (Row) o;
            return Arrays.equals(data, row.data);
        }

        @Override
        public int hashCode() {
            return data != null ? Arrays.hashCode(data) : 0;
        }

        private void checkAccess(long cookie) {
            if (isLocked() && lockCookie != cookie) {
                throw new SecurityException(msg.get("db.error.row.locked"));
            }
        }

        public void setValid(boolean valid) {
            this.valid = valid;
        }
    }

    private class FieldDescription {
        private byte length;
        private String name;

        public FieldDescription(byte length, String name) {
            this.length = length;
            this.name = name;
        }

        public byte getLength() {
            return length;
        }

        public String getName() {
            return name;
        }
    }

    /**
     * constructor
     * @param db path to local database file
     * @throws IOException if some error while file parsing
     * @throws IllegalArgumentException if db path is null or empty
     */
    public Data(String db) throws IOException {
        if (db == null || db.length() == 0) {
            throw new IllegalArgumentException(msg.get("db.error.path.empty"));
        }
        DataInputStream dis = null;
        this.db = db;
        try {
            FileInputStream fis = new FileInputStream(db);
            FileChannel ch = fis.getChannel();
            dis = new DataInputStream(fis);

            int magic = dis.readInt();
            if (magic != MAGIC) {
                throw new IOException(msg.get("db.error.magic"));
            }
            fieldsCount = dis.readShort();
            if (fieldsCount <= 0) {
                throw new IOException(msg.get("db.error.rowcount", fieldsCount));
            }
            headers = new FieldDescription[fieldsCount];
            for (int i = 0; i < fieldsCount; ++i) {
                byte len = dis.readByte();
                if (len <= 0) {
                    throw new IOException(msg.get("db.error.row.name.length"));
                }
                byte[] buf = new byte[len];
                int readed = dis.read(buf);
                if (readed != len) {
                    throw new IOException(msg.get("db.error.row.name.read"));
                }
                String fieldName = new String(buf, ENC);
                byte fieldLength = dis.readByte();

                headers[i] = new FieldDescription(fieldLength, fieldName);
            }

            while (dis.available() > 0) {
                long position = ch.position();
                int flag = dis.readUnsignedByte();
                String[] data = new String[fieldsCount];
                for (int i = 0; i < fieldsCount; ++i) {
                    byte[] buf = new byte[headers[i].getLength()];
                    int readed = dis.read(buf);
                    if (buf.length != readed) {
                        throw new IOException(msg.get("db.error.row.read"));
                    }
                    data[i] = new String(buf, ENC);
                }
                Row row = new Row(flag, data, position);
                rows.add(row);
                if (!row.isValid()) {
                    deleted.add(rows.size() - 1);
                }
            }
        } finally {
            if (dis != null) {
                try {
                    dis.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    /**
     * reads a record
     * @param recNo record id
     * @return record data
     * @throws RecordNotFoundException if record not found
     */
    public String[] read(int recNo) throws RecordNotFoundException {
        Row r = get(recNo);
        return r.getData();
    }

    /**
     * updates a record
     * @param recNo record id
     * @param data new record data
     * @param lockCookie lock cookie
     * @throws RecordNotFoundException if record not found
     * @throws SecurityException if record is locked with another lock cookie
     * @throws IllegalArgumentException if data is null or has illegal length
     */
    public synchronized void update(int recNo, String[] data, long lockCookie) throws RecordNotFoundException, SecurityException, IllegalArgumentException {
        validate(data);
        final Row r = get(recNo);
        r.setData(data, lockCookie);
        try {
            write(r);
        } catch (InvalidDBFormatException e) {
            e.printStackTrace();
        }
    }

    /**
     * deletes a record
     * @param recNo record id
     * @param lockCookie lock cookie of record
     * @throws RecordNotFoundException if record not found
     * @throws SecurityException if record locked with another lock cookie
     */
    public synchronized void delete(int recNo, long lockCookie) throws RecordNotFoundException, SecurityException {
        final Row r = get(recNo);
        r.delete(lockCookie);
        try {
            write(r);
        } catch (InvalidDBFormatException e) {
            e.printStackTrace();
        }
        deleted.add(recNo);
    }

    /**
     * finds a records with matched criteria
     * @param criteria array of string criteria, if field starts with criteria, it putted in result
     * @return array of record ids
     * @throws IllegalArgumentException if criteria length has length more than record fields count
     */
    public int[] find(String[] criteria) {
        if (criteria == null) {
            criteria = new String[0];
        }

        if (criteria.length > fieldsCount) {
            throw new IllegalArgumentException(msg.get("db.error.criteria.size"));
        }
        LinkedList<Integer> res = new LinkedList<Integer>();
        //counting non empty criteria fields
        int notNull = 0;
        for (String cr : criteria) {
            if (cr != null && cr.length() > 0) {
                notNull++;
            }
        }
        //select indicies of non empty criteria fields
        int[] cols = new int[notNull];
        int index = 0;
        for (int i = 0; i < criteria.length; ++i) {
            if (criteria[i] != null && criteria[i].length() > 0) {
                cols[index++] = i;
            }
        }
        //search rows with matched criteria
        for (int i = 0; i < rows.size(); ++i) {
            Row r = rows.get(i);
            if (!r.isValid()) {
                continue;
            }
            boolean match = true;
            String[] data = r.getData();
            for (int j = 0; j < cols.length && match; ++j) {
                int col = cols[j];
                match = data[col].startsWith(criteria[col]);
            }
            if (match) {
                res.add(i);
            }
        }
        int[] out = new int[res.size()];
        index = 0;
        for (Integer i : res) {
            out[index++] = i;
        }
        return out;
    }

    /**
     * creates a new record
     * @param data record data
     * @return id of new record
     * @throws DuplicateKeyException
     * @throws IllegalArgumentException if data is null or has illegal length
     */
    public synchronized int create(String[] data) throws DuplicateKeyException {
        validate(data);
        int index;
        Row r;
        if (deleted.size() > 0) {
            index = deleted.poll();
            r = rows.get(index);
            r.setData(data, 0);
            r.setValid(true);
        } else {
            r = new Row(data);
            rows.add(r);
            index = rows.size() - 1;
        }
        try {
            write(r);
        } catch (InvalidDBFormatException e) {
            e.printStackTrace();
        }
        return index;
    }

    /**
     * locks a record
     * @param recNo record id
     * @return lock cookie, if successfull, lock cookie > 0, else -1
     * @throws RecordNotFoundException if row isn't found
     */
    public long lock(int recNo) throws RecordNotFoundException {
        final Row r = get(recNo);
        long lock = nextLockCookie = (nextLockCookie % (Long.MAX_VALUE - 1)) + 1;
        try {
            r.lock(lock);
        } catch (LockCancelledException e) {
            lock = -1;
        }
        return lock;
    }

    /**
     * unlocks a record
     * @param recNo record id
     * @param cookie lock cookie
     * @throws RecordNotFoundException if record isn't exists
     * @throws SecurityException if record is locked with another lock cookie
     */
    public void unlock(int recNo, long cookie) throws RecordNotFoundException, SecurityException {
        final Row r = get(recNo);
        r.unlock(cookie);
    }


    private Row get(int index) throws RecordNotFoundException {
        if (index < 0 || index > rows.size()) {
            throw new RecordNotFoundException(msg.get("db.error.row.index"));
        }
        Row r = rows.get(index);
        if (!r.isValid()) {
            throw new RecordNotFoundException(msg.get("db.error.row.deleted"));
        }
        return rows.get(index);
    }


    private void validate(String[] data) {
        if (data == null || data.length != fieldsCount) {
            throw new IllegalArgumentException(msg.get("db.error.fields.illegal.count"));
        }
        for (int i = 0; i < fieldsCount; ++i) {
            if (data[i] != null && data[i].length() > headers[i].getLength()) {
                throw new IllegalArgumentException(msg.get("db.error.field.length", headers[i].getName()));
            }
        }
    }

    private synchronized void write(Row r) throws InvalidDBFormatException {
        String[] data = r.getData();

        try {
            RandomAccessFile file = new RandomAccessFile(db, "rw");
            long pos = r.getPosition();
            if (pos < 0) {
                pos = file.length();
            }
            file.seek(pos);
            file.writeByte(r.getFlag());

            for (int i = 0; i < fieldsCount; ++i) {
                String d = tail(data[i], headers[i].getLength());
                file.write(d.getBytes(ENC));
            }
            file.close();
        } catch (FileNotFoundException e) {
            throw new InvalidDBFormatException(msg.get("db.error.file.absent"));
        } catch (IOException e) {
            throw new InvalidDBFormatException(e.getMessage());
        }
    }

    private String tail(String in, int length) {
        if (in == null) {
            in = "";
        }
        String out;
        if (in.length() >= length) {
            out = in;
        } else {
            StringBuffer sb = new StringBuffer(in);
            while (sb.length() < length) {
                sb.append(" ");
            }
            out = sb.toString();
        }
        return out;
    }
}

